這個部份我大概卡了一、兩個禮拜,還沒卡完,雖然是30天連續發文的鐵人賽,但是 side project 應該會做超過30天。
把東西疊在一起之後開始出現問題,腦子開始有點混亂,看著存稿5、4、3...的減少,十分焦慮,只好讓大家看看壞掉的過程,以下是其中一次試誤。
今天的程式碼 Day8
為了圖層互動的功能把 html 標籤的 img 改成用 SVG 包起來
但平面圖圖層在拖曳的時候,一直有微妙的偏移,問題應該是在於我設定了三層 <svg>
, <g>
, 和 <image>
,跟之前 image 只有一層不一樣,跟 Claude 反應之後,他是說確實可能是這個結構不一樣,所以座標系統也不一樣,之前的拖曳計算基於容器的座標系統,不是基於 SVG 座標系統轉換。
所以
screenToSVGPoint
方法,用於將螢幕座標轉換為 SVG 座標。mousedown
、mousemove
和 mouseup
事件中,使用 screenToSVGPoint
來計算 SVG 座標系中的位置。updateTransform
方法,直接在 transform
字串中應用 panX
和 panY
。SVG在工作上比較少遇到,所以有點鴨子聽雷,總之是會正常平移了
<div class="svg-container" #container>
<svg #svgElement [attr.viewBox]="viewBox">
<g [attr.transform]="transform">
<image [attr.href]="svgHref" />
</g>
</svg>
<div #debugIndicator class="debug-indicator"></div>
</div>
<div class="debug-info">
Mouse position: ({{debugInfo.x | number:'1.0-0'}}, {{debugInfo.y | number:'1.0-0'}})
</div>
<div class="controls">
<button (click)="zoomIn()">放大</button>
<button (click)="zoomOut()">縮小</button>
<button (click)="reset()">重置</button>
<button (click)="rotateClockwise()">順時針旋轉90°</button>
<button (click)="rotateCounterclockwise()">逆時針旋轉90°</button>
</div>
.svg-container {
width: 100%;
height: 75vh;
border: 1px solid #ccc;
overflow: hidden;
position: relative;
}
svg {
width: 100%;
height: 100%;
}
.controls {
margin-top: 10px;
}
.debug-indicator {
position: absolute;
width: 10px;
height: 10px;
background-color: red;
border-radius: 50%;
pointer-events: none;
}
.debug-info {
position: absolute;
bottom: 10px;
left: 10px;
background-color: rgba(0, 0, 0, 0.7);
color: white;
padding: 5px;
font-size: 12px;
}
import {
AfterViewInit,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
ElementRef,
EventEmitter,
OnDestroy,
OnInit,
Output,
ViewChild,
} from '@angular/core';
import {
fromEvent,
Subject,
Subscription,
takeUntil,
throttleTime,
} from 'rxjs';
import { ZoomService } from './zoom.service';
@Component({
selector: 'app-floor-plan',
templateUrl: './floor-plan.component.html',
styleUrls: ['./floor-plan.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FloorPlanComponent implements OnInit, AfterViewInit, OnDestroy {
@ViewChild('svgElement', { static: true })
svgElement!: ElementRef<SVGSVGElement>;
@ViewChild('container') container!: ElementRef<HTMLDivElement>;
@ViewChild('debugIndicator') debugIndicator!: ElementRef<HTMLDivElement>;
@Output() svgReady = new EventEmitter<SVGSVGElement>();
@Output() scaleChanged = new EventEmitter<number>();
@Output() transformChanged = new EventEmitter<{
zoom: number;
panX: number;
panY: number;
rotation: number;
}>();
@Output() svgDimensionsChanged = new EventEmitter<{
width: number;
height: number;
}>();
svgHref = 'assets/floor-plan.svg';
viewBox = '0 0 100 100';
private originalWidth = 100;
private originalHeight = 100;
private zoom = 1;
private panX = 0;
private panY = 0;
private rotation = 0;
private resizeSubscription?: Subscription;
private destroy$ = new Subject<void>();
debugInfo = { x: 0, y: 0, svgX: 0, svgY: 0 };
get transform(): string {
return this.zoomService.calculateTransform(
this.zoom,
this.panX,
this.panY,
this.rotation,
this.originalWidth,
this.originalHeight
);
}
constructor(
private zoomService: ZoomService,
private cd: ChangeDetectorRef
) {}
// Lifecycle methods
ngOnInit(): void {
this.loadSvgDimensions();
}
ngAfterViewInit(): void {
this.setupZoom();
this.setupResizeListener();
this.svgReady.emit(this.svgElement.nativeElement);
this.scaleChanged.emit(this.zoom);
}
ngOnDestroy(): void {
this.resizeSubscription?.unsubscribe();
this.destroy$.next();
this.destroy$.complete();
}
// Public methods
getSvgDimensions() {
return {
original: { width: this.originalWidth, height: this.originalHeight },
display: this.getDisplayDimensions(),
};
}
zoomIn(): void {
this.zoom = this.zoomService.zoomIn(this.zoom);
this.updateTransform();
}
zoomOut(): void {
this.zoom = this.zoomService.zoomOut(this.zoom);
this.updateTransform();
}
reset(): void {
this.zoom = 1;
this.panX = 0;
this.panY = 0;
this.rotation = 0;
this.updateTransform();
}
rotateClockwise(): void {
this.rotation = (this.rotation + 90) % 360;
this.updateTransform();
}
rotateCounterclockwise(): void {
this.rotation = (this.rotation - 90 + 360) % 360;
this.updateTransform();
}
// Private methods
private loadSvgDimensions(): void {
const img = new Image();
img.onload = () => {
this.originalWidth = img.width;
this.originalHeight = img.height;
this.viewBox = `0 0 ${this.originalWidth} ${this.originalHeight}`;
this.updateDisplayDimensions();
this.svgDimensionsChanged.emit({
width: this.originalWidth,
height: this.originalHeight,
});
this.cd.markForCheck();
};
img.src = this.svgHref;
}
private updateDisplayDimensions(): void {
if (this.svgElement?.nativeElement) {
const svg = this.svgElement.nativeElement;
const displayWidth = svg.width.baseVal.value;
const displayHeight = svg.height.baseVal.value;
console.log(`Display dimensions: ${displayWidth}x${displayHeight}`);
}
}
private setupZoom(): void {
const element = this.container.nativeElement;
let isDragging = false;
let startX = 0;
let startY = 0;
fromEvent<MouseEvent>(element, 'mousedown')
.pipe(takeUntil(this.destroy$))
.subscribe((event) => {
isDragging = true;
const svgPoint = this.screenToSVGPoint(event.clientX, event.clientY);
startX = svgPoint.x - this.panX;
startY = svgPoint.y - this.panY;
console.log('Mousedown:', {
startX,
startY,
panX: this.panX,
panY: this.panY,
});
event.preventDefault();
});
fromEvent<MouseEvent>(document, 'mousemove')
.pipe(takeUntil(this.destroy$), throttleTime(16))
.subscribe((event) => {
if (!isDragging) return;
event.preventDefault();
requestAnimationFrame(() => {
const svgPoint = this.screenToSVGPoint(event.clientX, event.clientY);
this.panX = svgPoint.x - startX;
this.panY = svgPoint.y - startY;
console.log('Mousemove:', {
clientX: event.clientX,
clientY: event.clientY,
svgX: svgPoint.x,
svgY: svgPoint.y,
panX: this.panX,
panY: this.panY,
});
this.updateTransform();
this.updateDebugInfo(event);
});
});
fromEvent<MouseEvent>(document, 'mouseup')
.pipe(takeUntil(this.destroy$))
.subscribe((event) => {
if (!isDragging) return;
isDragging = false;
const svgPoint = this.screenToSVGPoint(event.clientX, event.clientY);
this.panX = svgPoint.x - startX;
this.panY = svgPoint.y - startY;
console.log('Mouseup:', {
clientX: event.clientX,
clientY: event.clientY,
svgX: svgPoint.x,
svgY: svgPoint.y,
panX: this.panX,
panY: this.panY,
});
this.updateTransform();
this.updateDebugInfo(event);
});
fromEvent<WheelEvent>(element, 'wheel', { passive: true })
.pipe(takeUntil(this.destroy$))
.subscribe((event) => {
const rect = element.getBoundingClientRect();
const offsetX = event.clientX - rect.left;
const offsetY = event.clientY - rect.top;
const prevZoom = this.zoom;
const delta = event.deltaY > 0 ? 0.9 : 1.1;
this.zoom = this.zoomService.zoomTo(this.zoom, delta);
// 調整 panX 和 panY 以保持縮放點不變
this.panX -= (offsetX / prevZoom - offsetX / this.zoom) * this.zoom;
this.panY -= (offsetY / prevZoom - offsetY / this.zoom) * this.zoom;
this.updateTransform();
});
}
private screenToSVGPoint(screenX: number, screenY: number): DOMPoint {
const svg = this.svgElement.nativeElement;
const pt = svg.createSVGPoint();
pt.x = screenX;
pt.y = screenY;
return pt.matrixTransform(svg.getScreenCTM()?.inverse());
}
private updateTransform(): void {
const transform = `translate(${this.panX}, ${this.panY}) scale(${this.zoom}) rotate(${this.rotation})`;
this.scaleChanged.emit(this.zoom);
this.transformChanged.emit({
zoom: this.zoom,
panX: this.panX,
panY: this.panY,
rotation: this.rotation,
});
console.log('Transform updated:', {
transform,
zoom: this.zoom,
panX: this.panX,
panY: this.panY,
rotation: this.rotation,
});
this.cd.markForCheck();
}
private updateDebugInfo(event: MouseEvent): void {
const rect = this.container.nativeElement.getBoundingClientRect();
const svgPoint = this.screenToSVGPoint(event.clientX, event.clientY);
this.debugInfo = {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
svgX: svgPoint.x,
svgY: svgPoint.y,
};
this.debugIndicator.nativeElement.style.left = `${this.debugInfo.x}px`;
this.debugIndicator.nativeElement.style.top = `${this.debugInfo.y}px`;
console.log('Debug info:', this.debugInfo);
this.cd.markForCheck();
}
private setupResizeListener(): void {
this.resizeSubscription = fromEvent(window, 'resize').subscribe(() => {
this.reset();
});
}
private getDisplayDimensions() {
if (this.svgElement?.nativeElement) {
const svg = this.svgElement.nativeElement;
return {
width: svg.width.baseVal.value,
height: svg.height.baseVal.value,
};
}
return { width: 0, height: 0 };
}
}
處理ZoomTo、In、Out功能用的service
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root',
})
export class ZoomService {
private minZoom = 0.1;
private maxZoom = 10;
constructor() {}
zoomTo(currentZoom: number, delta: number): number {
const newZoom = currentZoom * delta;
return Math.max(this.minZoom, Math.min(this.maxZoom, newZoom));
}
zoomIn(currentZoom: number): number {
return this.zoomTo(currentZoom, 1.2);
}
zoomOut(currentZoom: number): number {
return this.zoomTo(currentZoom, 0.8);
}
calculateTransform(
zoom: number,
panX: number,
panY: number,
rotation: number,
width: number,
height: number
): string {
const centerX = width / 2;
const centerY = height / 2;
return `
translate(${panX + centerX}, ${panY + centerY})
scale(${zoom})
rotate(${rotation})
translate(${-centerX}, ${-centerY})
`;
}
}
然後copy一個正方形出來瞎搞,跟之前的矩形元件基本上一樣
主要就是多兩個方法,一個是固定正方形的方法、一個是跟著畫面縮放的方法,其他微調跟完整的程式碼,進前面提供的 Day8
fixPosition() {
console.log('squire fix');
this.isFixed = true;
const target = document.querySelector('.target') as HTMLElement;
console.log('target', target);
if (target) {
this.originalTransform = window.getComputedStyle(target).transform;
}
}
private updateWithFloorPlanTransform() {
if (this.isFixed) {
const target = document.querySelector('.target') as HTMLElement;
if (target) {
const { zoom, panX, panY, rotation } = this.floorPlanTransform;
const matrix = new DOMMatrix(this.originalTransform);
const scale = zoom;
const translateX = panX + matrix.e * scale;
const translateY = panY + matrix.f * scale;
const newTransform = `translate(${translateX}px, ${translateY}px) scale(${scale}) rotate(${rotation}deg)`;
target.style.transform = newTransform;
}
}
}
還有Scss需要調整
.target {
width: 100px;
height: 100px;
background-color: rgba(76, 175, 80, 0.7);
color: white;
display: flex;
justify-content: center;
align-items: center;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
cursor: move;
position: absolute;
}
最後是操作層,要傳送兩個元件的資料,變形、旋轉、移動跟固定
<div class="svg-container mt-5">
<app-floor-plan
(transformChanged)="onFloorPlanTransformChanged($event)"
></app-floor-plan>
<div class="overlay-container">
<app-square [floorPlanTransform]="floorPlanTransform"></app-square>
</div>
</div>
<button class="mt-5" (click)="fixSquarePosition()" class="mt-5">
Fix Square Position
</button>
- {
pointer-events: auto;
}
上面這個要記得,不然碰不到正方形元件,不然就是碰到了無法變形旋轉,我也還沒想到更好的寫法,先這樣
.svg-container {
position: relative;
width: 100%;
height: 75vh;
}
.overlay-container {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
// 允許 SquareComponent 接收鼠標事件
> * {
pointer-events: auto;
}
}
// 確保 FloorPlanComponent 在 SquareComponent 下方
app-floor-plan {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1;
}
// 確保 SquareComponent 在 FloorPlanComponent 上方
app-square {
position: absolute;
z-index: 2;
}
.mt-5{
margin-top: 5rem;
}
import { Component, ViewChild, AfterViewInit, ChangeDetectorRef } from '@angular/core';
import { SquareComponent } from './square/square.component';
import { FloorPlanComponent } from './floor-plan/floor-plan.component';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.scss']
})
export class AppComponent {
@ViewChild(SquareComponent, { static: false }) squareComponent!: SquareComponent;
@ViewChild(FloorPlanComponent, { static: false }) floorPlanComponent!: FloorPlanComponent;
floorPlanTransform = { zoom: 1, panX: 0, panY: 0, rotation: 0 };
constructor(private cdr: ChangeDetectorRef) {}
ngAfterViewInit() {
this.cdr.detectChanges();
}
onFloorPlanTransformChanged(transform: { zoom: number; panX: number; panY: number; rotation: number }) {
this.floorPlanTransform = transform;
}
fixSquarePosition() {
if (this.squareComponent) {
this.squareComponent.fixPosition();
} else {
console.error('Square component is not initialized');
}
}
}
以上的程式碼是一張平面圖釘上家具之後,家具定位錯誤還會四處飛的例子
簡單說就是在廁所釘上馬桶之後,旋轉平面圖,馬桶會跑到客廳
或是放大之後馬桶直接先離開平面圖這樣的效果
如果拖曳的話可以飛更遠,十分刺激
推測錯誤是中心定位或是座標系統的關係
不然就是打掉重練,多試試看幾種寫法